リクエスト署名を検証するAPIをContentfulアプリから呼び出してブログの下書きをチェックしてみた

リクエスト署名を検証するAPIをContentfulアプリから呼び出してブログの下書きをチェックしてみた

Clock Icon2024.10.17

リテールアプリ共創部@大阪の岩田です。

先日こんな記事を書きました。

https://dev.classmethod.jp/articles/create-blog-review-api-with-bedrock/

この記事で紹介しているAPIは実際にDevelopersIOの記事を執筆する際にVS Codeのプラグインから呼び出してブログの下書きをチェックするために利用されています。ただ、VS Codeのプラグインを利用する形だとVS Code以外の執筆環境を利用している人はこのAPIの恩恵を受けられないという課題があります。

そこでDevelopersIOのバックエンドで利用されているContentfulのアプリを自前で開発してContentfulの編集画面と統合できないか検証してみました。

参考までにContentful含めたDevelopersIOの基盤については以下の動画で詳しく紹介されているので、興味があればご視聴ください。

https://youtu.be/OCnNOq_0VtI?si=5YiU227u5Noki6sh

概要

こんな感じの構成を作ります。

構成図

最終的にこうなります。
動作確認の様子

処理の流れは以下の通りです。

  1. ユーザーがContentfulの編集画面を開く
  2. Contentfulのコンテンツに加えて自作ContentfulアプリのHTMLやJSファイルも返却される
  3. 自作ContentfulアプリのJSが実行され、リクエスト署名を付与してレビュー用のAPIが呼び出される
  4. API GatewayがLambda Authorizerを呼び出す
  5. Lambda AuthorizerはAPI Gatewayからリクエストヘッダを受け取り、対象Contentfulアプリの署名として妥当かを検証する
  6. 検証結果がOKの場合は後続処理へ...

APIを呼び出せるのはContenfulにログインしているユーザーに限定するため、Contenfulのアプリからリクエストを発行する際は署名を付与してもらい、この署名をLambda Authorizerにて検証します。この署名を付与/検証する機構がContetful側で用意されているので、うまくこの機構に乗っかりましょう。

環境

今回利用した環境は以下のとおりです。

  • バックエンドAPI
    • Node.js: 20x
    • @contentful/node-apps-toolkit: 3.9.0
  • Contentfulアプリ
    • @contentful/app-sdk: 4.29.1
    • @contentful/f36-components: 4.74.0
    • @contentful/f36-tokens: 4.1.0
    • @contentful/react-apps-toolkit: 1.2.16
    • contentful-management: 10.46.4

Contenfulアプリの作成

まずContentfulのアプリを作成します。詳細な手順は割愛するので、必要に応じて以下のブログなどを参照してください。

https://dev.classmethod.jp/articles/contentful-apps-tutorial/

今回はブログ記事の下書きをAPIでレビューしたいので、LocationsにはEntry editorを選択しておきましょう。

Contentfulアプリの設定 - General

Lambda Authorizerでリクエストを検証できるようにSigning secretを作成しておきます。

Contentfulアプリの設定 - Security

アプリが作成できたらアプリを利用するスペースにインストールしておきましょう。

バックエンドAPIの作成

ここからアプリを実装していきます。まずはバックエンドのAPIです。

SAMテンプレート

以下のSAMテンプレートで関連リソースをデプロイします。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  contentful-backend-api
Globals:
  Function:
    Timeout: 3
    Runtime: nodejs20.x
    Tracing: Active
  Api:
    TracingEnabled: true
Parameters:
  ContentfulRequestSignSecret:
    Type: String
Resources:
  Api:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prd
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: oas.yaml
      Auth:
        Authorizers:
          authorizer:
            FunctionArn: !GetAtt AuthorizerFunction.Arn
            FunctionPayloadType: REQUEST
            Identity:
              ReauthorizeEvery: 0
              Headers:
                - X-Contentful-Signature
  AuthorizerFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: authorizer/
      Handler: app.lambdaHandler
      Architectures:
      - arm64
      Environment:
        Variables:
          CONTENTFUL_REQUEST_SIGN_SECRET: !Ref ContentfulRequestSignSecret
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: es2020
        Sourcemap: true
        EntryPoints:
        - app.ts

パラメータのContentfulRequestSignSecretには先ほどContentfulで作成した署名用のシークレットを渡します。渡されたシークレットはLambda Authorizerで利用するLambdaの環境変数にセットします。実際に業務で利用する場合はSSM Parameter StoreやSecrets Managerを利用するように修正した方が良いですが、今回は検証目的なので手抜きします。

Open APIの定義

SAMテンプレートから読み込んでいるOpen APIの定義は以下の通りです。

openapi: 3.0.1
info:
  title:
    Blog Review API
  version: '1.0'
paths:
  /:
    post:
      responses:
        "200":
          description: "200 response"
          headers:
            Access-Control-Allow-Origin:
              schema:
                type: "string"          
          content:
            application/json:
              schema:
                type: object
                properties:
                  typo:
                    type: string
                    example: タイポ警察だ!                    
                  mediaPolicy:
                    type: string
                    example: 特に問題ありません
      security:
        - authorizer: []      
      x-amazon-apigateway-integration:
        responses:
          default:
            statusCode: "200"
            responseParameters:
              method.response.header.Access-Control-Allow-Origin: "'*'"            
            responseTemplates:
              application/json: |
                {
                  "typo": "タイポ警察だ!",
                  "mediaPolicy": "特に問題ありません"
                }
        requestTemplates:
          application/json: "{\"statusCode\": 200}"
        passthroughBehavior: "when_no_match"
        type: "mock"
    options:
      responses:
        "200":
          description: "200 response"
          headers:
            Access-Control-Allow-Origin:
              schema:
                type: "string"
            Access-Control-Allow-Methods:
              schema:
                type: "string"
            Access-Control-Allow-Headers:
              schema:
                type: "string"
          content:
            application/json:
              schema:
                type: object
      x-amazon-apigateway-integration:
        type: "mock"
        responses:
          default:
            statusCode: "200"
            responseParameters:
              method.response.header.Access-Control-Allow-Methods: "'POST'"
              method.response.header.Access-Control-Allow-Headers:
                "'Content-Type,X-Contentful-Space-Id,X-Contentful-Environment-Id,X-Contentful-User-Id,X-Contentful-Signed-Headers,X-Contentful-Signature,X-Contentful-Timestamp,X-Contentful-Crn'"
              method.response.header.Access-Control-Allow-Origin: "'*'"
        requestTemplates:
          application/json: "{\"statusCode\": 200}"
        passthroughBehavior: "when_no_match"
components: 
  securitySchemes:
    authorizer:
      type: "apiKey"
      name: "X-Contentful-Signature"
      in: "header"
      x-amazon-apigateway-authtype: "custom"
      x-amazon-apigateway-authorizer:
        authorizerUri:
          Fn::Sub: "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthorizerFunction.Arn}/invocations"
        authorizerResultTtlInSeconds: 300
        identitySource: "method.request.header.X-Contentful-Signature"
        type: "request"

下書きレビュー用のAPIは POST /に定義しています。今回はモックレスポンスで固定のレスポンスを返却していますが、最終的にこの部分は前回紹介したStep Functions × BedrockのAPIに置き換える想定です。

ポイントとしてContentfulのアプリからAPIを呼び出せるようにCORSの設定も入れています。プリフライトリクエストに対して返却するレスポンスヘッダのAccess-Control-Allow-Headersにはリクエストの署名に必要なX-Contentful...系のヘッダの指定が必要です。今回は検証目的のためAccess-Control-Allow-Originは*としていますが、実際に業務で利用する場合は適切に制限しましょう。

また、簡略化のためにLambda Authorizerのチェックに引っかかって403エラーになった場合のレスポンスの定義を省略しています。ContentfulアプリからAPIを呼び出す際に署名の作成ミスなどで403エラーが発生するとCORSエラーが発生するので、ここも実業務で利用する際は追加実装が必要なポイントです。

Lambda Authorizerの実装

Contentfulのリクエスト署名を検証するLambda Authorizerの実装です。

import { APIGatewayRequestAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda';
import { verifyRequest } from '@contentful/node-apps-toolkit';

const secret = process.env.CONTENTFUL_REQUEST_SIGN_SECRET?? '';

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';

export const lambdaHandler = async (event: APIGatewayRequestAuthorizerEvent): Promise<APIGatewayAuthorizerResult> => {

    const { headers  } = event;
    const canonicalRequest = {
        path: event.requestContext.path,
        headers: headers as Record<string, string>,
        method: event.httpMethod as HttpMethod,
    };
    let isValid = false;

    try {
        isValid = verifyRequest(secret, canonicalRequest);
    } catch (e: unknown) {
        console.log(e);
    }    

    let principalId = '';
    if (headers != null) {
        principalId = headers['x-contentful-user-id'] ?? '';
    }

    const createPolicy = (effect: 'Deny' | 'Allow') => {
        return {
            principalId,
            policyDocument: {
                Version: '2012-10-17',
                Statement: [
                    {
                        Action: 'execute-api:Invoke',
                        Effect: effect,
                        Resource: event.methodArn,
                    },
                ],
            }
        }
    }
    if(isValid === false) {
        return createPolicy('Deny');
    }
    return createPolicy('Allow');
};

署名対象のcanonicalRequestを生成する以下のしょりですが、pathに指定するのはevent.pathではなくevent.requestContext.pathになることに注意してください。また、Lambda Authorizerはリクエストボディにアクセスできないため、bodyも含めていません。この部分についてはContentfulアプリ側で署名を生成する際もリクエストボディは無視する必要があるので要注意です。

const canonicalRequest = {
    path: event.requestContext.path,
    headers: headers as Record<string, string>,
    method: event.httpMethod as HttpMethod,
};

canonicalRequestさえ適切に生成できれば、あとはverifyRequestを呼び出すだけでライブラリがよしなにやってくれます。

一通りバックエンドの準備ができたらsam buildでビルド後に sam deploy --parameter-overrides ContentfulRequestSignSecret=<Contentfulで作成した署名用のシークレット>で一式デプロイしておきましょう。

Contentfulアプリの実装

続いてAPIを呼び出すフロントエンドのContentfulアプリを実装します。

まず以下のコマンドでアプリの雛形を作成します。

npx create-contentful-app アプリ名

今回はEntry Editorを拡張したいので、src/locations/EntryEditor.tsxを以下のように更新します。

import React, { useState } from 'react';
import { Button, Heading, Paragraph } from '@contentful/f36-components';
import { EditorAppSDK } from '@contentful/app-sdk';
import { useSDK } from '@contentful/react-apps-toolkit';

const Entry = () => {
  const sdk = useSDK<EditorAppSDK>();
  const [reviewResult, setReviewResult] = useState({
    typo: '',
    mediaPolicy: '',
  });
  const review = async () => {
    const contentsField = sdk.entry.fields['<ContentfulのContent modelに定義したフィールドのID>'];
    const contents = contentsField.getValue().content[0].content[0].value;
    const req = {
      method: 'POST',
      url: 'https://<バックエンドAPIのエンドポイント>/prd/',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ article: contents }),
    } as const;

    const { additionalHeaders } = await sdk.cma.appSignedRequest.create({
      appDefinitionId: sdk.ids.app?? '',
    }, {
      method: req.method,
      headers: req.headers,
      // Lambda Authorizerはbodyを受け取れないので署名に含めない
      // body: req.body,
      path: new URL(req.url).pathname,
    });
    Object.assign(req.headers, additionalHeaders);
    const res = await fetch(req.url, req);
    const reviewResult = await res.json();
    setReviewResult(reviewResult)
  }

  return <>
    <Heading>タイポチェックの結果</Heading>
    <Paragraph>{reviewResult.typo}</Paragraph>
    <Heading>メディアポリシー準拠チェックの結果</Heading>
    <Paragraph>{reviewResult.mediaPolicy}</Paragraph>
    <Button onClick={review}>レビュー</Button>
</>
};

export default Entry;

アプリの利用対象とするContent Modelは以下のような定義で事前に作成しています。

Content Modelの定義

contentsというIDでリッチテキストの入力欄を定義しているので、このIDを指定してsdk.entry.fieldsで入力値を取得してリクエストボディにセットします。

リクエストの準備ができたら以下の部分でリクエスト署名を生成し、リクエストヘッダに付与します。

const { additionalHeaders } = await sdk.cma.appSignedRequest.create({
  appDefinitionId: sdk.ids.app?? '',
}, {
  method: req.method,
  headers: req.headers,
  // Lambda Authorizerはbodyを受け取れないので署名に含めない
  // body: req.body,
  path: new URL(req.url).pathname,
});
Object.assign(req.headers, additionalHeaders);

あとは下書きレビュー用のAPIを呼び出してレスポンスを画面に描画するだけです。今回は検証目的なので、エラーハンドリングは省略しています。

動作確認してみる

ここまででバックエンドAPIとContentfulアプリの準備が完了しました。Contentfulアプリをローカルで起動しつつ、簡単に動作確認してみましょう。

動作確認の様子

レビューボタンをクリックするとレビュー用のAPIにリクエストを発行し、結果を画面に表示できているのが分かります。あとはバックエンドAPIでモックレスポンスを返却している部分をStep Functions & Bedrockと統合したり、フロントエンドの見栄えを調整したり、諸々手抜きしている箇所を仕上げればうまく実運用できそうな感触です。

最後に開発者ツールからCopy as cURLでコマンドをコピーし、リクエストヘッダのx-contentful-signatureを一部書き換えてターミナルに貼り付けてみましょう。

curl 'https://<APIのエンドポイント>/prd/' \-H 'accept: */*' \-H 'accept-language: ja,en-US;q=0.9,en;q=0.8' \-H 'content-type: application/json' \-H 'origin: http://localhost:3000' \-H 'priority: u=1, i' \-H 'referer: http://localhost:3000/' \-H 'sec-ch-ua: "Google Chrome";v="129", "Not=A?Brand";v="8", "Chromium";v="129"' \-H 'sec-ch-ua-mobile: ?0' \-H 'sec-ch-ua-platform: "macOS"' \-H 'sec-fetch-dest: empty' \-H 'sec-fetch-mode: cors' \-H 'sec-fetch-site: cross-site' \-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36' \-H 'x-contentful-crn: crn:contentful:::extensibility:spaces/...略' \-H 'x-contentful-environment-id: master' \-H 'x-contentful-signature: ...略' \-H 'x-contentful-signed-headers: content-type,x-contentful-crn,x-contentful-environment-id,x-contentful-signed-headers,x-contentful-space-id,x-contentful-timestamp,x-contentful-user-id' \-H 'x-contentful-space-id: ...略' \-H 'x-contentful-timestamp: 1729153084089' \-H 'x-contentful-user-id: ...略' \
∙   --data-raw '{"article":"これはテストです"}'

以下のようなレスポンスが返却されました。

{"Message":"User is not authorized to access this resource with an explicit deny"}

Lambda Authorizerで正しくリクエストに署名が検証できてそうですね。

まとめ

ブログの下書きをレビューするアプリをContentfulのアプリと統合してみました。リクエスト署名を検証する機構とLambda Authorizerを組み合わせることで未認証ユーザーからのAPI呼び出しを防止できるのが便利ですね。

参考

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.